Skip to content

perf(desktop): bound renderer in-memory retention with LRU eviction - #5596

Open
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/bound-renderer-accumulators
Open

perf(desktop): bound renderer in-memory retention with LRU eviction#5596
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/bound-renderer-accumulators

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 11, 2026

Copy link
Copy Markdown
Member

Bounds the desktop renderer's unbounded in-memory retention families with whole-unit LRU eviction and explicit pins, so RAM plateaus during sustained multi-agent use with zero data loss and zero stale-state corruption. Three retention families were unbounded and grew linearly with the number of channels/agents a session touches; this replaces that growth with a fixed working set.

What changed

1. Observer archive → LRU channel window (observerRelayStore.ts, observerArchiveEviction.ts)
archiveEventsByChannel is capped to an LRU window of channels. Eviction pairs deletion with the existing applyChannelReset generation mechanism, so a reopened channel starts at a null cursor with initialHydrationDone cleared and an in-flight decrypt/ingest cannot repopulate an evicted key. Mounted (agent, channel) keys are pinned.

2. Observer live-event window → tiered by viewer pin (observerRelayStore.ts, useObserverEvents.ts)
The live display window keeps the full 3,000-event window only for pinned viewers (explicit keyed viewer refcounts, not listener presence — the listener set has non-viewer consumers). Unpinned agents keep a small tail. transcriptByAgent is evicted/rebuilt together with the display window and snapshotByAgent is invalidated atomically, so no second retained representation survives eviction. A compact per-agent operational summary is kept separate from the evictable display history. The non-viewer snapshot consumers (active-turn, prevent-sleep, auto-restart, latest-session, profile feed) are covered by tests proving identical behavior after eviction.

3. Message-window query cache → LRU timeline window (messageWindowEviction.ts, boundMessageWindows.ts, useBoundedMessageWindows.ts)
Inactive channels' channelWindow + paired channelMessages keys are evicted together as a whole unit (dropping one alone tears state — channel pages are an echoed-cursor chain and channelWindowStore throws on discontinuity). Threads are bounded independently. Pins: any of the unit's queries isActive(), or the unit holds an optimistic pending send (including the cross-key send-from-thread case via liveOverlay). Reopen re-fetches a clean head from a null cursor; guarded background writes are a verified no-op on an evicted key, so eviction can't be silently resurrected.

The eviction policy reuses React Query's own signals — isActive() for pins, dataUpdatedAt for recency, data for pending sends — so there is no parallel bookkeeping to keep in sync. The sweep is mounted once via a 3-line addition in App.tsx's CommunityQueryProvider (the inner client all message keys live on).

Plateau receipt

Disposable harness drives the real enforceMessageWindowBounds against a real QueryClient through an N-channel roaming session, measured in isolated per-mode processes so each heap delta is attributable to its own cache. Auto-refresh is inherently disabled (Option D is not on this branch).

Channels roamed Unbounded retained Unbounded heap growth Bounded retained Bounded heap growth
100 100 +4.8 MiB 12 (= cap) +0.7 MiB
500 500 +24 MiB 12 (= cap) +0.8 MiB
1000 1000 +47.8 MiB 12 (= cap) +0.8 MiB

Unbounded retention and heap grow linearly with channels roamed; bounded plateaus at MAX_RETAINED_MESSAGE_CHANNELS = 12 with flat heap regardless of session length.

Tests

Eviction tests cover deterministic LRU/pinning, pending-send protection, null-cursor reopen, and history-exhausted semantics after rehydrate. Real-path deferred-writer tests evict a unit mid-flight, resolve the deferred write, and assert both the window-store and flattened-array keys stay absent — one per fenced writer: reactions hydration, structural aux backfill, ancestor loads, and mutation completion. ingestArchivedObserverEvents gets channel-switch and unmount tests: block inside the decrypt, force the reset/dispose, resolve, and assert the old archive page is dropped whole rather than torn.

Post-eviction consumer equivalence. The tiered live-event window truncates an unpinned agent's eventsByAgent/transcriptByAgent/snapshotByAgent to the newest-N tail. Each of the five non-viewer consumers of that state maps to a covering test:

Consumer Reads Covering test
profile feed live snapshot + transcript + durable channelActivity summary deriveProfileActivityFeedScope fold suite (5 cases) + test_channel_dropped_from_tail_survives_in_summary
preventSleep events[last] only test_newest_event_survives_tail_for_prevent_sleep
activeAgentTurns activeAgentTurnsStore (per-notify watermark, independent of the event array) test_active_turns_survive_event_eviction
working-state derives 1:1 from getActiveTurnsForAgent + typing store — no event-array read covered transitively by test_active_turns_survive_event_eviction
auto-restart connectionState scalar + working-state — no event-array read covered transitively via active-turns + connection state
latest-session getLatestLiveSessionId reads the independent latestLiveSessionByAgentChannel map (never truncated); the session-panel label derives from displayEvents only while the agent is pinned (full window) structurally isolated from truncation — no equivalence test needed

The durable per-agent channelId → latest-activity-ms summary is fed on the live-append path only and survives tail truncation; archive-ingested channel events are intentionally outside it because they never contributed to feed scope on main (ingestArchivedObserverEvents routes them to the channel-scoped archive window, not the live store). This scope decision is documented at the summary equivalence suite.

Full desktop unit suite: 4779 pass / 0 fail. scroll-history.spec.ts (smoke): 18 passed.

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 11, 2026 20:11
Duncan and others added 3 commits August 12, 2026 00:13
The channel-scoped observer archive (`archiveEventsByChannel`) grew only by
explicit paged loads from SQLite and was never evicted, so a long session that
visits many channels retains every channel's full scroll-back for the lifetime
of the app — a renderer memory leak behind the v0.5.9 lag.

Bound it to MAX_RETAINED_ARCHIVE_CHANNELS distinct channels via an LRU policy
that evicts whole channels (every agent entry for a channel goes together).
Channels with a mounted panel are pinned (refcounted) and never evicted; an
evicted channel re-hydrates from SQLite on revisit, so eviction is a cache miss,
not data loss. A post-unmount fence stops an in-flight page from resurrecting an
evicted channel after its pin is released.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The per-agent live-event stores (`eventsByAgent`/`transcriptByAgent`/the
memoized snapshot) each kept the full MAX_OBSERVER_EVENTS window, and a
long-lived session observes many agents over time — so the accumulator grew
with the number of distinct agents seen and never shrank, one of the renderer
leaks behind the v0.5.9 lag.

Tier the window by whether a session viewer is mounted. A viewer (the two
session panels and the activity bar, via useObserverEvents/useAgentTranscript)
pins its agent with a refcount, keeping the full window so the deep scroll-back
it displays is never truncated under it. An unpinned agent — observed only by
background consumers that never display the transcript — is bounded to
UNPINNED_AGENT_EVENT_TAIL. Unpinning the last viewer immediately re-bounds the
window it accumulated, rebuilding events, transcript, and snapshot atomically
before notifying so no consumer sees a transcript referencing dropped events.

The tail is safe for every non-viewer consumer: preventSleep reads only the
newest event (always retained); the active-turns bridge processes each event
once as it arrives, with turn state living in its own store and gated by a
per-channel watermark, so a turn_started aging out of the tail cannot lose the
turn; and the profile activity feed derives a channel set that degrades to the
tail (its durable history is channel-scoped in SQLite).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ndow

Every visited channel and thread leaves its query-cache keys in place for
gcTime (one hour) after the view unmounts, so a session that roams many
channels holds every timeline's window store and flattened render array in
RAM at once — a renderer memory growth behind the v0.5.9 scroll/input lag.
gcTime bounds how long an inactive query lingers, not how many linger
concurrently; this adds the missing concurrency bound.

A cache subscription mounted once in CommunityQueryProvider evicts the
least-recently-updated unpinned timelines past a fixed cap. Channels and
threads are bounded independently so a burst of open threads never evicts
channel scrollback. A unit is pinned while any of its queries has a mounted
observer or holds an optimistic pending send. Eviction removes both channel
keys together and reads back absent, so revisit re-fetches from SQLite and a
guarded background write cannot resurrect an evicted key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/bound-renderer-accumulators branch from 55580f4 to dfa8469 Compare August 12, 2026 05:43
…tomic

Close the pass-3 review findings on the bounded message-window / observer-archive work.

F1: a per-unit generation guard (updateRetainedMessageUnit / messageUnitGuard)
fences deferred writers — reaction and aux hydration, ancestor loads, edit and
delete mutation handlers, and the profile-wave send — so a write that settles
after its unit was evicted can neither resurrect the dropped key nor stale-merge
onto a re-fetched timeline. A key-presence backstop in projectChannelWindowMessages
covers the refresh path that has no pin.

F2: reaction hydration claims are cleared on eviction.

F3: ingestArchivedObserverEvents is two-phase — decrypt every frame into a
staging buffer, then commit the whole page under a single isStale() gate with no
await between check and append, so a channel switch or unmount mid-decrypt drops
the page whole rather than tearing it.

F4: a durable per-agent channelId to latest-activity-ms summary survives the
unpinned event-tail truncation and feeds the profile feed's channel scope.

observerRelayStore.ts had grown past the 1000-line file-size ratchet, so the
channel-activity summary and the pure event-ordering / batch-unwrap helpers move
to channelActivitySummary.ts and observerEventOrdering.ts, re-exported from the
store to keep import paths stable.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/bound-renderer-accumulators branch from dfa8469 to 6c4122f Compare August 12, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant